Skip to content

UID2-6764: Add SLSA build provenance attestations to docker publish workflows#228

Merged
BehnamMozafari merged 15 commits into
mainfrom
bmz-UID2-6764-artifact-attestation
May 11, 2026
Merged

UID2-6764: Add SLSA build provenance attestations to docker publish workflows#228
BehnamMozafari merged 15 commits into
mainfrom
bmz-UID2-6764-artifact-attestation

Conversation

@BehnamMozafari
Copy link
Copy Markdown
Contributor

@BehnamMozafari BehnamMozafari commented May 6, 2026

Summary

Adds SLSA build-provenance attestation to every non-snapshot image published by the shared docker workflows.

  • New composite action actions/attest_image wraps the full attest+verify path: it lowercases the image ref once, calls actions/attest@v4.1.0 (pinned to 59d8942), and runs gh attestation verify against the just-pushed digest. Verify hard-fails on every consumer; the SAN policy accepts either the reusable-workflow signer (IABTechLab/uid2-shared-actions) or a composite-caller signer (see "Verify SAN policy" below).
  • Both shared-publish-java-to-docker-versioned.yaml and actions/shared_publish_to_docker/action.yaml now call attest_image@v3 instead of inlining the attest block.
  • Publish jobs gain id-token: write and attestations: write.
  • Attestation is skipped on snapshot builds via the existing not_snapshot guard.

Closes UID2-6764. Spike was UID2-5763.

Smoke test evidence

1. Unit smoke (composite action in isolation)

Run 25542801315attest_image against a throwaway alpine image built inline. External verify:

$ gh attestation verify \
    "oci://ghcr.io/iabtechlab/uid2-shared-actions/test-attest@sha256:e008cbdd1c67eee898020ad96d56ff0d42d762585ef4c1153479abaf5a4112bb" \
    --owner "IABTechLab"
✓ Verification succeeded!

- Attestation #1
  - Build repo:..... IABTechLab/uid2-shared-actions
  - Build workflow:. .github/workflows/test-attest-image.yaml@refs/heads/bmz-UID2-6764-artifact-attestation
  - Signer repo:.... IABTechLab/uid2-shared-actions
  - Signer workflow: .github/workflows/test-attest-image.yaml@refs/heads/bmz-UID2-6764-artifact-attestation

2. End-to-end smoke (full shared workflow chain)

Run 25645229683 on UnifiedID2/uid2-test-source (private/internal), branch release-UID2-6764-smoke. Workflow conclusion: success, every step green including the in-CI gh attestation verify. Exercised in order: shared_publish_setup → docker login → docker metadata → docker build (with load: true) → actions/vulnerability_scan@v3 → docker push (push: true) → actions/attest_image (lowercase → actions/attest@v4.1.0 → in-CI verify) → actions/shared_create_releases@v2 (draft release).

Attestation created for ghcr.io/unifiedid2/uid2-test-source/uid2-6764-smoke@sha256:07b8a50e71f0b5d4731e37204709cdcc52a9d5a57c05508658356f1d771060d1, signed by GitHub's internal Sigstore instance, uploaded to both the GitHub attestations API and the OCI registry. No verify warning; the in-CI gh attestation verify hard-passes against the reusable-workflow signer.

Verify SAN policy

gh attestation verify enforces two things against a bundle: a source-repo-owner extension (set by --owner) and a signer-identity SAN regex. With only --owner, the SAN regex defaults to ^https://github.com/<owner>/, which silently rejects every reusable-workflow consumer — because the cert SAN for a reusable workflow is the reusable workflow's own URL, not the caller's repo (see cli/cli#9045 and gh attestation verify --help).

attest_image is invoked from two places:

  1. The reusable workflow shared-publish-{,java-}to-docker-versioned.yaml — the reusable workflow itself is the signer, so SAN is under IABTechLab/uid2-shared-actions/.
  2. The composite action actions/shared_publish_to_docker invoked directly by a caller — the caller's workflow is the signer, so SAN is under the caller's repo (${{ github.repository }}).

attest_image passes --cert-identity-regex "^https://github\.com/(IABTechLab/uid2-shared-actions|${{ github.repository }})/" to accept either pattern. There is no private-repo special case: the wrapper error Error: verifying with issuer "GitHub, Inc." returned by gh CLI swallows the underlying no matching certificate identity and reflects whichever Sigstore CA signed the bundle (public-good for public repos, GitHub's internal CA for private/internal repos) — neither is itself the blocker.

Test plan

  • Snapshot smoke on IABTechLab/uid2-admin (Java path) — run 25421656856not_snapshot guard verified.
  • Unit smoke of attest_imagerun 25542801315 — sign + verify green.
  • End-to-end smoke of the full non-Java shared workflow chain — run 25645229683 — green on a private/internal repo, in-CI verify hard-passes against the reusable-workflow signer.
  • Release-tag E2E on each public consumer's first real publish after v3 is promoted; verified digests recorded in UID2-6764.

Post-merge sequence

  1. Merge this PR.
  2. Run update-major-version-tags.yaml on main immediately after — the refactored workflows reference actions/attest_image@v3, and consumers triggering between merge and tag promotion would fail with "action not found".
  3. Merge each of the 6 caller-repo follow-up PRs below.
  4. On each public consumer's first real publish, capture the verified digest in UID2-6764.
  5. Delete IABTechLab/uid2-admin:bmz-UID2-6764-test (the snapshot smoke pin branch).

Caller-repo follow-up — already opened (one PR each, all open)

Repo PR
IABTechLab/uid2-operator #2531
IABTechLab/uid2-core #403
IABTechLab/uid2-admin #632
IABTechLab/uid2-optout #402
UnifiedID2/uid2-snowflake #299
UnifiedID2/uid2-databricks #132

Each grants id-token: write + attestations: write (plus the implicit defaults the publish job already relied on). They're additive and harmless until this PR merges and v3 is promoted.

SDK images are explicitly out of scope; follow-up ticket to be filed separately.

BehnamMozafari and others added 5 commits May 6, 2026 13:46
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Attestation runs after the docker push but before the changelog/release
steps. Without continue-on-error, an attest failure leaves a half-finished
release: image pushed, no GitHub Release created. Tolerate attest failures
during the v3 rollout so consumers aren't stuck mid-release if attestation
breaks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jon8787
Copy link
Copy Markdown
Contributor

jon8787 commented May 8, 2026

Can we do a real smoke test without "Attest build provenance" step being skipped?
e.g. using uid2-test-source or similar?

Comment thread actions/shared_publish_to_docker/action.yaml Outdated
Comment thread .github/workflows/shared-publish-java-to-docker-versioned.yaml Outdated
Comment thread actions/shared_publish_to_docker/action.yaml Outdated
Comment thread actions/shared_publish_to_docker/action.yaml Outdated
BehnamMozafari and others added 10 commits May 8, 2026 17:19
Addresses jon8787's review comments on PR #228:
- #2 verify step: attest_image now calls 'gh attestation verify' immediately
  after signing so misconfigured signatures fail at build time, not consumer
  pull time.
- #3 case sensitivity: lowercase the image ref once and reuse it for both
  signing and verifying. actions/attest@v4 already lowercases subject-name
  internally when push-to-registry is true (verified at the pinned commit
  59d8942 in src/main.ts and src/subject.ts), but 'gh attestation verify'
  does NOT lowercase the OCI URI we pass it; doing it ourselves keeps the
  signed name and the verified URI byte-identical.
- #4 NODE_OPTIONS comment: brief comment explaining why we mirror
  actions/attest-build-provenance's defensive HTTP header bump.
- #5 extract: pulled the attest+verify pair into a single composite action
  so the Java workflow and the non-Java composite action share one
  implementation.

Adds .github/workflows/test-attest-image.yaml: a manually-dispatched smoke
test that builds a throwaway image and exercises the full attest+verify
path. Use this whenever attest_image or actions/attest@v4 changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drop after merge — only here so the smoke test can run before the workflow
file lands on main (gh workflow run / API dispatch require the file to
exist on the default branch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
github.repository is mixed case; docker rejects mixed-case tags at push
time. Compute a lowercased ref once and reuse it for the push tag, the
attest_image input, and the independent re-verify command.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…st is green

Run 25542801315 verified the attest+verify path end-to-end. Reverting to
workflow_dispatch only so the test stops auto-firing and remains as an
on-demand regression check after merge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run 25542801315 captured the verified attestation evidence on PR #228;
keeping the workflow would just push throwaway test images on every
manual dispatch. The composite action lives at actions/attest_image
and can be re-tested in any future change by re-adding this workflow
file ad-hoc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end smoke against private UnifiedID2/uid2-test-source surfaced a
real gh CLI limitation: attestations signed by GitHub's internal Sigstore
instance (used for private repos) fail verification with
'Error: verifying with issuer "GitHub, Inc."'. Tried --no-public-good,
--bundle-from-oci, --cert-oidc-issuer combinations; same result.

Signing and upload still succeed (bundle lands in both the attestations
API and the OCI registry), so external verifiers remain authoritative.
Demote the in-CI verify failure to a warning for private repos only;
public repos still hard-fail on verify mismatch as Jon's review #2
intended.

Evidence: UnifiedID2/uid2-test-source actions run 25643422322 — full
shared-publish-to-docker-versioned.yaml chain green (setup → buildx →
vulnerability_scan → push → attest_image sign+upload → shared_create_releases
draft), attestation signed for ghcr.io/.../uid2-6764-smoke@sha256:05058e77...

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
actions/attest@v4.1.0 sets create-storage-record:true by default, which
calls GitHub's artifact-metadata API to cross-link the signed attestation
to the build artifact. Without artifact-metadata:write the call returns
403 and the run logs 'Failed to persist storage record'.

The storage record itself is independent of the signature/upload chain
(those still succeed), but it powers the "Attestations" tab in the
GitHub UI and surfaces attestations for org-wide policy/discovery.
Required on the callee's job permissions block too; reusable workflows
take the intersection of caller and callee permissions.

Consumer rollout PRs receive a matching grant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous "private-repo verify caveat" misdiagnosed cli/cli#9045:
the failure isn't about the GitHub-internal Sigstore CA, it's that the
cert SAN of a reusable-workflow signer doesn't match the default
--owner regex. Add --cert-identity-regex to accept either signer
pattern (reusable workflow under IABTechLab/uid2-shared-actions, or
composite-action caller under ${{ github.repository }}), and remove
the warning demotion so verify hard-fails uniformly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jon8787
Copy link
Copy Markdown
Contributor

jon8787 commented May 11, 2026

I think he order of events is: build & push :1.2.3 (tag) → attest → verify → changelog → create release
If attest or verify fail, we still have a consumable image. An alternative would be to creating the tag as the last step.
Do you think we should create another ticket to address this?

@BehnamMozafari
Copy link
Copy Markdown
Contributor Author

@BehnamMozafari BehnamMozafari merged commit 4bcb568 into main May 11, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants